Project

General

Profile

Feature #43462 » vanilla_styling_and_more_settings_v1.10.5.js.txt

Jimmy Westberg, 2025-11-14 19:40

 
1
// Redmine History Timeline Navigator — Quick Jump (v1.10.5)
2
// - Panel docks left/right via CONFIG.SIDE
3
// - Separate bottom offsets for Quick Jump trigger vs. panel
4
// - Minimal custom styling to blend with page theme
5
// - Configurable pill border-radius (CONFIG.PILL_RADIUS)
6
// - Configurable highlight colors via CONFIG.HIGHLIGHT_COLOR / CONFIG.HIGHLIGHT_SOFT_COLOR
7
// - All UI text configurable via CONFIG.TEXT (except console output)
8
// - Configurable z-index base via CONFIG.ZINDEX_BASE (all layers offset from this)
9
// - Panel width/height fixed via CONFIG.PANEL_WIDTH / CONFIG.PANEL_HEIGHT
10
// - Preview opens to the opposite side of the panel (left when panel is on the right)
11
// - Panel & preview & slider thumb reuse nearest non-transparent background color
12
// - No visual highlight on first init (only internal mark)
13
// - Preview de-dup (no heading if missing/duplicated)
14
// - Pills: overflow-x:hidden + ellipsis, always stacked (no spreading)
15
// - "Only notes" hides journals with only .has-details (no .has-notes) and hidden journals
16
// - "Only jump to long notes" uses CONFIG.LONG_NOTES_CHARACTER_THRESHOLD
17
// - Slider & counter respect filters (including "no results" → 0/0)
18
// - No "bounce" when scrolling active pill into view
19
// - Highlight also styles the tab header, TITLE for "long notes"
20
// - Lazy Load override with progress indicator (OVERRIDE_DEFAULT_LAZY_LOAD / LOAD_*)
21
// - Loading indicator is embedded in header (no layout jump)
22

    
23
(function () {
24
  const CONFIG = {
25
    PANEL_WIDTH: 280,
26
    PANEL_HEIGHT: 380,
27

    
28
    // 'left' | 'right'
29
    SIDE: 'left',
30

    
31
    // Horizontal offset from chosen side
32
    SIDE_OFFSET: 6,
33

    
34
    // Bottom offsets
35
    BOTTOM_OFFSET_QUICK: 60,  // Quick Jump button / hover zone
36
    BOTTOM_OFFSET_PANEL: 65,  // Panel
37

    
38
    // Pill appearance
39
    PILL_RADIUS: 999,         // px, e.g. 999 for capsule, 6 for rounded
40

    
41
    // Threshold for "long notes" (characters)
42
    LONG_NOTES_CHARACTER_THRESHOLD: 500,
43

    
44
    NOTE_TARGET_PAD: 80,
45
    PREVIEW_MAX_CHARS: 220,
46
    PREVIEW_IMG_MAX_W: 160,
47
    PREVIEW_IMG_MAX_H: 100,
48

    
49
    // Highlight colors
50
    HIGHLIGHT_COLOR: 'rgba(37,99,235,0.85)',
51
    HIGHLIGHT_SOFT_COLOR: 'rgba(37,99,235,0.6)',
52
    HIGHLIGHT_FADE_MS: 700,
53

    
54
    // Base z-index (all other layers are relative to this)
55
    // hoverzone:      ZINDEX_BASE
56
    // launcher:       ZINDEX_BASE + 1
57
    // panel:          ZINDEX_BASE + 2
58
    // preview-popup:  ZINDEX_BASE + 3
59
    // hint-tooltip:   ZINDEX_BASE + 4
60
    ZINDEX_BASE: 9990,
61

    
62
    // Lazy-load policy
63
    // false  = do nothing extra, respect Redmine's lazy load (default, unchanged behavior)
64
    // true = QuickJump will trigger loading more history in steps + show indicator
65
    OVERRIDE_DEFAULT_LAZY_LOAD: false,
66

    
67
    // How aggressively we click "show more" in steps
68
    // (per run of loadAllLazyHistory; set higher if you have LOTS of journals)
69
    LOAD_ALL_MAX_STEPS: 80,
70
    LOAD_ALL_STEP_DELAY_MS: 400,
71

    
72
    // All user-visible text (except console)
73
    TEXT: {
74
      LAUNCHER_LABEL: 'Quick jump',
75
      PANEL_TITLE: 'History timeline',
76
      CLOSE_LABEL: 'Close',
77
      CLOSE_TITLE: 'Close',
78
      PREV_BUTTON: '⟨',
79
      PREV_TITLE: 'Previous (p/k)',
80
      NEXT_BUTTON: '⟩',
81
      NEXT_TITLE: 'Next (n/j)',
82
      EDGE_OLDEST: 'Oldest',
83
      EDGE_NEWEST: 'Newest',
84
      ONLY_NOTES_LABEL: 'Only notes',
85
      ONLY_NOTES_TITLE: 'Checked = show only journals with notes (has-notes) and ignore hidden journals. Unchecked = include all entries.',
86
      ONLY_LONG_LABEL: 'Only jump to long notes',
87
      ONLY_LONG_TITLE: 'Checked = only jump to notes longer than {N} characters.',
88
      FILTER_PLACEHOLDER: 'Filter: author, date or #',
89
      PILLS_ARIA_LABEL: 'Quick jump',
90
      HELP_HTML: 'Keyboard shortcuts:<br><b>n/j</b> next, <b>p/k</b> previous, <b>g</b> go to #',
91
      NO_RESULTS: 'No results match the filter.',
92
      PROMPT_GOTO: 'Go to comment # (e.g. 10):',
93
      ALERT_GOTO_NOT_FOUND_PREFIX: 'Could not find #',
94
      HINT_JUMP_PREFIX: 'Jump to ',
95
      LOAD_LABEL: 'Reading history…',
96
      LOAD_DONE: 'History reading done.'
97
    }
98
  };
99

    
100
  const HISTORY_SEL = '#tab-content-history';
101
  // const NOTE_SEL = '.note[id^="note-"]';  // Redmine 6
102
  const NOTE_SEL = 'div[id^="note-"]';       // older Redmine
103
  const JOURNAL_SEL = '.journal';
104

    
105
  const PANEL_ID = 'qj-history-timeline-panel';
106
  const STYLE_ID = PANEL_ID + '-style';
107
  const LAUNCH_ID = 'qj-history-launcher';
108
  const HOVERZONE_ID = 'qj-history-hoverzone';
109
  const PREVIEW_ID = 'qj-history-preview';
110
  const HINT_ID = 'qj-history-hint';
111
  const NOTE_HL_CLASS = 'qj-note-highlight';
112
  const NOTE_HEADER_HL_CLASS = 'qj-note-header-highlight';
113

    
114
  const HIGHLIGHT_RING = '0 0 0 2px ' + CONFIG.HIGHLIGHT_COLOR;
115

    
116
  // Clean up previous instance if present
117
  if (window.__QJ_HistoryTimeline?.destroy) {
118
    try { window.__QJ_HistoryTimeline.destroy(); } catch (e) {}
119
  }
120

    
121
  function ready(fn) {
122
    if (document.readyState === 'complete' || document.readyState === 'interactive') {
123
      setTimeout(fn, 0);
124
    } else {
125
      document.addEventListener('DOMContentLoaded', fn, { once: true });
126
    }
127
  }
128

    
129
  ready(bootstrap);
130

    
131
  let retryTimer = null;
132
  function bootstrap() {
133
    const tryInit = () => {
134
      const historyRoot = document.querySelector(HISTORY_SEL);
135
      if (!historyRoot) return false;
136
      init(historyRoot);
137
      return true;
138
    };
139
    if (!tryInit()) {
140
      let tries = 0;
141
      retryTimer = setInterval(() => {
142
        tries++;
143
        if (tryInit() || tries > 50) {
144
          clearInterval(retryTimer);
145
          retryTimer = null;
146
        }
147
      }, 200);
148
    }
149
  }
150

    
151
  // Find closest non-transparent background color starting from a given element.
152
  function findEffectiveBackgroundColor(rootEl) {
153
    const isTransparent = (bg) => {
154
      if (!bg) return true;
155
      bg = bg.trim().toLowerCase();
156
      if (bg === 'transparent') return true;
157
      return /^rgba?\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\)$/.test(bg);
158
    };
159

    
160
    let el = rootEl;
161
    while (el) {
162
      const cs = window.getComputedStyle(el);
163
      const bg = cs && cs.backgroundColor;
164
      if (!isTransparent(bg)) return bg;
165
      el = el.parentElement;
166
    }
167

    
168
    const htmlBg = window.getComputedStyle(document.documentElement).backgroundColor;
169
    if (!isTransparent(htmlBg)) return htmlBg;
170

    
171
    const bodyBg = window.getComputedStyle(document.body).backgroundColor;
172
    if (!isTransparent(bodyBg)) return bodyBg;
173

    
174
    return '#ffffff';
175
  }
176

    
177
  function init(historyRoot) {
178
    // Remove any previous instances of our elements
179
    [PANEL_ID, STYLE_ID, LAUNCH_ID, HOVERZONE_ID, PREVIEW_ID, HINT_ID].forEach(id => {
180
      const el = document.getElementById(id);
181
      if (el) el.remove();
182
    });
183
    document.querySelectorAll('.' + NOTE_HL_CLASS).forEach(el => el.classList.remove(NOTE_HL_CLASS));
184

    
185
    const esc = (s) => (s || '').replace(/[&<>"]/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[m]));
186
    const by = (sel, root = document) => root.querySelector(sel);
187

    
188
    // ----------------------------------------------------
189
    // Lazy-scroll mot note (tar hänsyn till lazy load)
190
    // ----------------------------------------------------
191
    const LAZY_SCROLL_CHECK_DELAY_MS = 1000;  // ~1 sekund mellan varje koll
192
    const LAZY_SCROLL_MAX_ATTEMPTS = 15;      // max antal försök (~15 sek totalt)
193

    
194
    let lazyScrollJob = null;
195

    
196
    function cancelLazyScroll() {
197
      if (lazyScrollJob && lazyScrollJob.timer) {
198
        clearTimeout(lazyScrollJob.timer);
199
      }
200
      lazyScrollJob = null;
201
    }
202

    
203
    // Cancel auto-scroll if the user is actively scrolling (wheel/touch/scroll keys)
204
    const userOverrideScroll = () => {
205
      if (!lazyScrollJob) return;
206
      cancelLazyScroll();
207
    };
208

    
209
    const userScrollKeyHandler = (e) => {
210
      if (!lazyScrollJob) return;
211
      const tag = (e.target.tagName || '').toLowerCase();
212
      if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
213
      const scrollKeys = ['PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' '];
214
      if (scrollKeys.includes(e.key)) {
215
        cancelLazyScroll();
216
      }
217
    };
218

    
219
    window.addEventListener('wheel', userOverrideScroll, { passive: true });
220
    window.addEventListener('touchmove', userOverrideScroll, { passive: true });
221
    window.addEventListener('keydown', userScrollKeyHandler);
222

    
223
    function isNoteInTargetViewport(el) {
224
      if (!el) return false;
225
      const rect = el.getBoundingClientRect();
226
      const vh = window.innerHeight || document.documentElement.clientHeight;
227

    
228
      const targetTop = CONFIG.NOTE_TARGET_PAD;
229
      const topOk = rect.top >= targetTop - 5 && rect.top <= targetTop + 5;
230
      const bottomOk = rect.bottom <= vh - 8;
231

    
232
      return topOk && bottomOk;
233
    }
234

    
235
    function scrollNoteTowardsTarget(el, behavior) {
236
      if (!el) return;
237
      const rect = el.getBoundingClientRect();
238
      const targetTop = CONFIG.NOTE_TARGET_PAD;
239
      const delta = rect.top - targetTop;
240

    
241
      if (Math.abs(delta) < 3) return;
242

    
243
      window.scrollTo({
244
        top: window.scrollY + delta,
245
        behavior: behavior || 'auto'
246
      });
247
    }
248

    
249
    function highlight(el, silent = false) {
250
      // Remove any existing highlight classes
251
      document.querySelectorAll('.' + NOTE_HL_CLASS).forEach(e => e.classList.remove(NOTE_HL_CLASS));
252
      document.querySelectorAll('.' + NOTE_HEADER_HL_CLASS).forEach(h => h.classList.remove(NOTE_HEADER_HL_CLASS));
253

    
254
      // Also clear any previous box-shadow so we don't stack effects
255
      document.querySelectorAll(NOTE_SEL).forEach(e => {
256
        e.style.boxShadow = 'none';
257
        e.style.transition = '';
258
      });
259

    
260
      if (silent) return;
261

    
262
      // Mark current note (for CSS hooks)
263
      el.classList.add(NOTE_HL_CLASS);
264

    
265
      // Add temporary highlight on the header "tab" of this note
266
      const header = el.querySelector('h4');
267
      if (header) {
268
        header.classList.add(NOTE_HEADER_HL_CLASS);
269
      }
270

    
271
      // Add a box-shadow ring around the whole note and fade it out
272
      el.style.transition = 'box-shadow 0.6s ease';
273
      el.style.boxShadow = HIGHLIGHT_RING;
274

    
275
      setTimeout(() => {
276
        el.style.boxShadow = '0 0 0 0 transparent';
277
        if (header) {
278
          header.classList.remove(NOTE_HEADER_HL_CLASS);
279
        }
280
      }, CONFIG.HIGHLIGHT_FADE_MS);
281
    }
282

    
283
    function jumpTo(el) {
284
      if (!el) return;
285

    
286
      const journal = el.closest(JOURNAL_SEL);
287
      if (journal && journal.style && journal.style.display === 'none') {
288
        journal.style.display = '';
289
      }
290

    
291
      // Cancel any previous job
292
      cancelLazyScroll();
293

    
294
      const startIndex = indexOf(el);
295

    
296
      // First “rough” scroll
297
      scrollNoteTowardsTarget(el, 'smooth');
298

    
299
      lazyScrollJob = {
300
        el,
301
        attempts: 0,
302
        timer: null
303
      };
304

    
305
      const tick = () => {
306
        if (!lazyScrollJob || lazyScrollJob.el !== el) return;
307

    
308
        // Never force the user back if the panel is closed
309
        if (!panel.classList.contains('open')) {
310
          cancelLazyScroll();
311
          return;
312
        }
313

    
314
        lazyScrollJob.attempts++;
315

    
316
        if (isNoteInTargetViewport(el) || lazyScrollJob.attempts >= LAZY_SCROLL_MAX_ATTEMPTS) {
317
          const idxNow = indexOf(el);
318
          if (idxNow >= 0) {
319
            setActiveIndex(idxNow);
320
          } else if (startIndex >= 0) {
321
            setActiveIndex(startIndex);
322
          }
323
          highlight(el);
324
          cancelLazyScroll();
325
          return;
326
        }
327

    
328
        // Redmine may have lazy-loaded more → adjust again
329
        scrollNoteTowardsTarget(el, 'smooth');
330

    
331
        lazyScrollJob.timer = setTimeout(tick, LAZY_SCROLL_CHECK_DELAY_MS);
332
      };
333

    
334
      lazyScrollJob.timer = setTimeout(tick, LAZY_SCROLL_CHECK_DELAY_MS);
335
    }
336

    
337
    // ----------------------------------------------------
338
    // Lazy-load handling (show more / load all history)
339
    // ----------------------------------------------------
340

    
341
    function findLazyLoadTrigger() {
342
      const selectors = [
343
        'a.show_more',
344
        'a[data-remote="true"].show-more',
345
        'a[data-remote="true"].load-more-journals',
346
        'a[href*="last_journal_id"]'
347
      ];
348

    
349
      for (const sel of selectors) {
350
        const link = historyRoot.querySelector(sel);
351
        if (link && link.offsetParent !== null) {
352
          return link;
353
        }
354
      }
355
      return null;
356
    }
357

    
358
    let historyFullyLoaded = false;
359
    let loadIndicator = null;
360
    let loadProgress = null;
361
    let loadLabelEl = null;
362

    
363
    function setLoadIndicator(active, text) {
364
      if (!loadIndicator) return;
365

    
366
      if (text && loadProgress) {
367
        loadProgress.textContent = text;
368
      } else if (loadProgress && !text) {
369
        loadProgress.textContent = '';
370
      }
371

    
372
      if (active) {
373
        loadIndicator.classList.add('active');
374
      } else {
375
        loadIndicator.classList.remove('active');
376
        // Rensa text när vi inte är aktiva för att inte visa "gammal" info
377
        if (loadLabelEl) loadLabelEl.textContent = '';
378
        if (loadProgress) loadProgress.textContent = '';
379
      }
380
    }
381

    
382
    function extractWhenFromHeader(header) {
383
      if (!header) return '';
384
      const links = Array.from(header.querySelectorAll('a[title]'));
385
      const dateLike = links.find(a => /\d{4}-\d{2}-\d{2}/.test(a.getAttribute('title') || ''));
386
      if (dateLike) return dateLike.getAttribute('title') || dateLike.textContent.trim();
387
      const act = links.find(a => /activity\?from=/.test(a.getAttribute('href') || ''));
388
      if (act) return act.getAttribute('title') || act.textContent.trim();
389
      const last = links[links.length - 1];
390
      return last ? (last.getAttribute('title') || last.textContent.trim()) : '';
391
    }
392

    
393
    function getSummaryAndBody(wikiEl) {
394
      const firstHeading = wikiEl.querySelector('h1,h2,h3,h4,h5,h6');
395
      const headingText = firstHeading?.innerText?.trim() || '';
396
      let bodyText = (wikiEl.innerText || '').replace(/\s+/g, ' ').trim();
397
      const norm = s => (s || '').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, ' ').trim();
398
      if (headingText && norm(bodyText).startsWith(norm(headingText))) {
399
        const idx = bodyText.toLowerCase().indexOf(headingText.toLowerCase());
400
        if (idx === 0) bodyText = bodyText.slice(headingText.length).trim();
401
      }
402
      const showTitle = !!headingText && !norm(bodyText.slice(0, 100)).includes(norm(headingText));
403
      const summary = showTitle ? headingText : '';
404
      return { summary, bodyText };
405
    }
406

    
407
    const collectNotes = (excludeHiddenJournals, onlyNotesFlag) => {
408
      const notes = Array.from(historyRoot.querySelectorAll(NOTE_SEL))
409
        .filter(n => {
410
          const j = n.closest(JOURNAL_SEL);
411

    
412
          // Hide hidden journals if requested
413
          if (excludeHiddenJournals && j && j.style && j.style.display === 'none') {
414
            return false;
415
          }
416

    
417
          // If we only want "real notes":
418
          // skip journals that are only .has-details and NOT .has-notes
419
          if (onlyNotesFlag && j) {
420
            const jc = j.classList;
421
            if (jc.contains('has-details') && !jc.contains('has-notes')) {
422
              return false;
423
            }
424
          }
425

    
426
          return true;
427
        })
428
        .sort((a, b) => {
429
          const an = parseInt(a.id.replace('note-', ''), 10);
430
          const bn = parseInt(b.id.replace('note-', ''), 10);
431
          if (!isNaN(an) && !isNaN(bn)) return an - bn;
432
          return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
433
        });
434

    
435
      return notes.map((note, i) => {
436
        const num = parseInt(note.id.replace('note-', ''), 10);
437
        const journal = note.closest(JOURNAL_SEL);
438
        const header = note.querySelector('.note-header') || journal?.querySelector('.note-header');
439
        const author = header?.querySelector('a.user')?.textContent?.trim() || 'Unknown';
440
        const linkTxt = journal?.querySelector('.journal-link')?.textContent?.trim() || ('#' + (isNaN(num) ? (i + 1) : num));
441
        const when = extractWhenFromHeader(header);
442
        const wiki = note.querySelector('.wiki') || note;
443
        const { summary, bodyText } = getSummaryAndBody(wiki);
444
        const imgSrc = (wiki.querySelector('img')?.getAttribute('src')) || null;
445
        return { el: note, num, author, linkTxt, when, textLen: bodyText.length, imgSrc, summary, fullText: bodyText };
446
      });
447
    };
448

    
449
    let excludeHidden = true;
450
    let onlyNotes = true;
451
    let notes = [];
452
    const indexOf = (el) => notes.findIndex(n => n.el === el);
453
    let activeIndex = 0;
454

    
455
    function loadAllLazyHistory(onDone) {
456
      // If we should respect lazy load → do nothing extra
457
      if (CONFIG.OVERRIDE_DEFAULT_LAZY_LOAD) {
458
        if (onDone) onDone();
459
        return;
460
      }
461

    
462
      if (historyFullyLoaded) {
463
        if (onDone) onDone();
464
        return;
465
      }
466

    
467
      const maxSteps = CONFIG.LOAD_ALL_MAX_STEPS || 80;
468
      const delay = CONFIG.LOAD_ALL_STEP_DELAY_MS || 400;
469

    
470
      let steps = 0;
471
      let lastCount = collectNotes(excludeHidden, onlyNotes).length;
472

    
473
      if (loadLabelEl) {
474
        loadLabelEl.textContent = CONFIG.TEXT.LOAD_LABEL || 'Reading history…';
475
      }
476
      setLoadIndicator(true, `${lastCount} noter`);
477

    
478
      const step = () => {
479
        const link = findLazyLoadTrigger();
480

    
481
        // CASE 1: no links left → we are truly done
482
        if (!link) {
483
          historyFullyLoaded = true;
484
          rebuildAll();
485
          if (loadLabelEl) {
486
            loadLabelEl.textContent = CONFIG.TEXT.LOAD_DONE || 'History reading done..';
487
          }
488
          setTimeout(() => setLoadIndicator(false), 800);
489
          if (onDone) onDone();
490
          return;
491
        }
492

    
493
		// CASE 2: we reached the max steps but there is still a link
494
		// → we stop, but do NOT mark historyFullyLoaded (a new run can continue)
495
        if (steps >= maxSteps) {
496
          console.warn('QuickJump: reached LOAD_ALL_MAX_STEPS, more history may remain. Increase CONFIG.LOAD_ALL_MAX_STEPS if needed.');
497
          rebuildAll();
498
          if (loadLabelEl) {
499
            loadLabelEl.textContent = CONFIG.TEXT.LOAD_DONE || 'History reading done..';
500
          }
501
          setTimeout(() => setLoadIndicator(false), 800);
502
          if (onDone) onDone();
503
          return;
504
        }
505

    
506
        steps++;
507
        try {
508
          link.click();
509
        } catch (e) {
510
          console.warn('QuickJump: lazy-load click error', e);
511
          // Don't know if everything is loaded → do NOT set historyFullyLoaded
512
          rebuildAll();
513
          if (loadLabelEl) {
514
            loadLabelEl.textContent = CONFIG.TEXT.LOAD_DONE || 'History reading done..';
515
          }
516
          setTimeout(() => setLoadIndicator(false), 800);
517
          if (onDone) onDone();
518
          return;
519
        }
520

    
521
        setTimeout(() => {
522
          const currCount = collectNotes(excludeHidden, onlyNotes).length;
523
          const diff = currCount - lastCount;
524
          lastCount = currCount;
525
          if (loadProgress) {
526
            // We don't know the total count, but we show how many notes we have so far
527
            loadProgress.textContent = `${currCount} noter${diff > 0 ? ` (+${diff})` : ''}`;
528
          }
529
          step();
530
        }, delay);
531
      };
532

    
533
      step();
534
    }
535

    
536
    const bottomQuick = CONFIG.BOTTOM_OFFSET_QUICK ?? CONFIG.BOTTOM_OFFSET ?? 60;
537
    const bottomPanel = CONFIG.BOTTOM_OFFSET_PANEL ?? CONFIG.BOTTOM_OFFSET ?? bottomQuick;
538
    const sideOffset = CONFIG.SIDE_OFFSET ?? 6;
539
    const side = (CONFIG.SIDE === 'right') ? 'right' : 'left';
540

    
541
    const zBase = CONFIG.ZINDEX_BASE ?? 9990;
542
    const zHover = zBase;
543
    const zLaunch = zBase + 1;
544
    const zPanel = zBase + 2;
545
    const zPreview = zBase + 3;
546
    const zHint = zBase + 4;
547

    
548
    const css = document.createElement('style');
549
    css.id = STYLE_ID;
550
    css.textContent = `
551
      :root {
552
        --qj-width: ${CONFIG.PANEL_WIDTH}px;
553
        --qj-height: ${CONFIG.PANEL_HEIGHT}px;
554
        --qj-bottom-quick: ${bottomQuick}px;
555
        --qj-bottom-panel: ${bottomPanel}px;
556
        --qj-side-offset: ${sideOffset}px;
557
        --qj-bg: #ffffff;
558
        --qj-highlight: ${CONFIG.HIGHLIGHT_COLOR};
559
        --qj-highlight-soft: ${CONFIG.HIGHLIGHT_SOFT_COLOR};
560
      }
561
      #${HOVERZONE_ID} {
562
        position: fixed;
563
        left: 50%;
564
        transform: translateX(-50%);
565
        bottom: var(--qj-bottom-quick);
566
        width: 360px;
567
        height: 42px;
568
        z-index: ${zHover};
569
      }
570
      #${LAUNCH_ID} {
571
        position: fixed;
572
        left: 50%;
573
        transform: translateX(-50%);
574
        bottom: calc(var(--qj-bottom-quick) - 10px);
575
        z-index: ${zLaunch};
576
        opacity: 0;
577
        transition: opacity .2s ease, transform .2s ease;
578
        cursor: pointer;
579
        padding: 4px 10px;
580
        font-size: 0.95em;
581
        white-space: nowrap;
582
      }
583
      #${HOVERZONE_ID}:hover + #${LAUNCH_ID},
584
      #${LAUNCH_ID}:hover {
585
        opacity: 1;
586
        transform: translateX(-50%) translateY(-2px);
587
      }
588
      #${PANEL_ID} {
589
        position: fixed;
590
        width: var(--qj-width);
591
        height: var(--qj-height);
592
        bottom: calc(var(--qj-bottom-panel) + 14px);
593
        max-width: calc(100vw - 24px);
594
        border: 1px solid rgba(0,0,0,0.12);
595
        border-radius: 10px;
596
        box-shadow: 0 8px 24px rgba(0,0,0,0.15);
597
        overflow: hidden;
598
        user-select: none;
599
        z-index: ${zPanel};
600
        opacity: 0;
601
        pointer-events: none;
602
        transition: transform .2s ease, opacity .2s ease;
603
        background-color: var(--qj-bg);
604
        display: flex;
605
        flex-direction: column;
606
      }
607
      #${PANEL_ID}.side-left {
608
        left: var(--qj-side-offset);
609
        right: auto;
610
        transform: translate(-120%, 0);
611
      }
612
      #${PANEL_ID}.side-right {
613
        right: var(--qj-side-offset);
614
        left: auto;
615
        transform: translate(120%, 0);
616
      }
617
      #${PANEL_ID}.open {
618
        transform: translate(0, 0);
619
        opacity: 1;
620
        pointer-events: auto;
621
      }
622
      #${PANEL_ID} .qj-hdr {
623
        display: flex;
624
        align-items: flex-start;
625
        justify-content: space-between;
626
        padding: 6px 10px 4px 10px;
627
        border-bottom: 1px solid rgba(0,0,0,0.08);
628
        font-size: 0.95em;
629
      }
630
      #${PANEL_ID} .qj-hdr-main {
631
        display: flex;
632
        flex-direction: column;
633
        gap: 1px;
634
      }
635
      #${PANEL_ID} .qj-title {
636
        font-weight: 600;
637
        line-height: 1.2;
638
      }
639
      #${PANEL_ID} .qj-load-indicator {
640
        font-size: 0.5em;
641
        opacity: 0;
642
        transition: opacity .18s ease;
643
        min-height: 0.6em;
644
      }
645
      #${PANEL_ID} .qj-load-indicator.active {
646
        opacity: 0.85;
647
      }
648
      #${PANEL_ID} .qj-load-indicator .qj-load-progress {
649
        margin-left: 4px;
650
        font-variant-numeric: tabular-nums;
651
      }
652
      #${PANEL_ID} .qj-counter {
653
        opacity: 0.8;
654
        font-variant-numeric: tabular-nums;
655
        margin-right: 4px;
656
        margin-top: 2px;
657
      }
658
      #${PANEL_ID} .qj-close {
659
        font-size: 0.9em;
660
        padding: 2px 6px;
661
        cursor: pointer;
662
        margin-top: 0;
663
      }
664
      #${PANEL_ID} .qj-controls {
665
        display: grid;
666
        grid-template-columns: auto 1fr auto;
667
        gap: 6px;
668
        align-items: center;
669
        padding: 6px 10px;
670
        font-size: 0.95em;
671
      }
672
      #${PANEL_ID} .qj-btn {
673
        cursor: pointer;
674
        padding: 2px 6px;
675
        font-size: 0.95em;
676
      }
677
      #${PANEL_ID} .qj-slider-wrap {
678
        display: flex;
679
        align-items: center;
680
        gap: 6px;
681
      }
682
      #${PANEL_ID} .qj-edge {
683
        font-size: 0.85em;
684
        opacity: 0.8;
685
        user-select: none;
686
      }
687
      #${PANEL_ID} .qj-slider {
688
        -webkit-appearance: none;
689
        appearance: none;
690
        width: 100%;
691
        height: 16px;
692
        background: transparent;
693
      }
694
      #${PANEL_ID} .qj-slider::-webkit-slider-runnable-track {
695
        height: 3px;
696
        background: rgba(0,0,0,0.12);
697
        border-radius: 999px;
698
      }
699
      #${PANEL_ID} .qj-slider::-moz-range-track {
700
        height: 3px;
701
        background: rgba(0,0,0,0.12);
702
        border-radius: 999px;
703
      }
704
      #${PANEL_ID} .qj-slider::-webkit-slider-thumb {
705
        -webkit-appearance: none;
706
        width: 12px;
707
        height: 12px;
708
        border-radius: 50%;
709
        border: 1px solid rgba(0,0,0,0.3);
710
        margin-top: -5px;
711
        background-color: var(--qj-bg);
712
      }
713
      #${PANEL_ID} .qj-slider::-moz-range-thumb {
714
        width: 12px;
715
        height: 12px;
716
        border-radius: 50%;
717
        border: 1px solid rgba(0,0,0,0.3);
718
        background-color: var(--qj-bg);
719
      }
720
      #${PANEL_ID} .qj-toggles {
721
        display: flex;
722
        gap: 8px;
723
        align-items: center;
724
        padding: 0 10px 6px 10px;
725
        flex-wrap: wrap;
726
        font-size: 0.95em;
727
      }
728
      #${PANEL_ID} .qj-toggles label {
729
        display: flex;
730
        gap: 4px;
731
        align-items: center;
732
      }
733
      #${PANEL_ID} .qj-filter {
734
        flex: 1;
735
        min-width: 120px;
736
        padding: 2px 6px;
737
        font-size: 0.95em;
738
      }
739
      #${PANEL_ID} .qj-pills {
740
        display: flex;
741
        flex-wrap: wrap;
742
        justify-content: flex-start;
743
        align-content: flex-start;
744
        gap: 4px;
745
        padding: 0 10px 8px 10px;
746
        overflow-y: auto;
747
        overflow-x: hidden;
748
        flex: 1 1 auto;
749
      }
750
      #${PANEL_ID} .qj-pill {
751
        font-size: 0.9em;
752
        padding: 2px 8px;
753
        border-radius: ${CONFIG.PILL_RADIUS}px;
754
        cursor: pointer;
755
        max-width: 100%;
756
        overflow: hidden;
757
        text-overflow: ellipsis;
758
        white-space: nowrap;
759
        border: 1px solid rgba(0,0,0,0.18);
760
      }
761
      #${PANEL_ID} .qj-pill.active {
762
        outline: 1px solid var(--qj-highlight-soft);
763
        outline-offset: 0;
764
      }
765
      #${PANEL_ID} .qj-help {
766
        font-size: 0.85em;
767
        opacity: 0.8;
768
        padding: 0 10px 8px 10px;
769
      }
770
      .${NOTE_HL_CLASS} { border-radius: inherit !important; }
771
      .${NOTE_HEADER_HL_CLASS}::before {
772
        border-right-color: var(--qj-highlight) !important;
773
      }
774
      #${PREVIEW_ID} {
775
        position: fixed;
776
        z-index: ${zPreview};
777
        pointer-events: none;
778
        max-width: 340px;
779
        border: 1px solid rgba(0,0,0,0.12);
780
        border-radius: 8px;
781
        box-shadow: 0 8px 24px rgba(0,0,0,0.18);
782
        padding: 6px;
783
        font-size: 0.85em;
784
        display: none;
785
        background-color: var(--qj-bg);
786
      }
787
      #${PREVIEW_ID} .ttl { font-weight: 600; margin-bottom: 4px; }
788
      #${PREVIEW_ID} .txt { line-height: 1.3; }
789
      #${PREVIEW_ID} .row {
790
        display: flex;
791
        gap: 6px;
792
        align-items: flex-start;
793
      }
794
      #${PREVIEW_ID} img {
795
        display: block;
796
        max-width: ${CONFIG.PREVIEW_IMG_MAX_W}px;
797
        max-height: ${CONFIG.PREVIEW_IMG_MAX_H}px;
798
        border-radius: 4px;
799
        border: 1px solid rgba(0,0,0,0.1);
800
      }
801
      #${HINT_ID} {
802
        position: fixed;
803
        z-index: ${zHint};
804
        pointer-events: none;
805
        background: #111;
806
        color: #fff;
807
        border-radius: 4px;
808
        padding: 2px 6px;
809
        font-size: 0.8em;
810
        box-shadow: 0 6px 18px rgba(0,0,0,0.2);
811
        display: none;
812
        white-space: nowrap;
813
      }
814
    `;
815
    document.head.appendChild(css);
816

    
817
    const hoverZone = document.createElement('div'); hoverZone.id = HOVERZONE_ID;
818
    const launcher = document.createElement('button'); launcher.id = LAUNCH_ID; launcher.textContent = CONFIG.TEXT.LAUNCHER_LABEL;
819
    document.body.appendChild(hoverZone); document.body.appendChild(launcher);
820

    
821
    const panel = document.createElement('div');
822
    panel.id = PANEL_ID;
823
    panel.className = (side === 'right') ? 'side-right' : 'side-left';
824
    const longTitle = (CONFIG.TEXT.ONLY_LONG_TITLE || '').replace('{N}', CONFIG.LONG_NOTES_CHARACTER_THRESHOLD);
825
    panel.innerHTML = `
826
      <div class="qj-hdr">
827
        <div class="qj-hdr-main">
828
          <span class="qj-title">${esc(CONFIG.TEXT.PANEL_TITLE)}</span>
829
          <div class="qj-load-indicator">
830
            <span class="qj-load-label">${esc(CONFIG.TEXT.LOAD_LABEL)}</span>
831
            <span class="qj-load-progress"></span>
832
          </div>
833
        </div>
834
        <div style="display:flex;align-items:flex-start;gap:6px;">
835
          <span class="qj-counter"></span>
836
          <button class="qj-close" title="${esc(CONFIG.TEXT.CLOSE_TITLE)}">${esc(CONFIG.TEXT.CLOSE_LABEL)}</button>
837
        </div>
838
      </div>
839
      <div class="qj-controls">
840
        <button class="qj-btn qj-prev" title="${esc(CONFIG.TEXT.PREV_TITLE)}">${esc(CONFIG.TEXT.PREV_BUTTON)}</button>
841
        <div class="qj-slider-wrap">
842
          <span class="qj-edge">${esc(CONFIG.TEXT.EDGE_OLDEST)}</span>
843
          <input type="range" class="qj-slider" min="0" value="0" step="1">
844
          <span class="qj-edge">${esc(CONFIG.TEXT.EDGE_NEWEST)}</span>
845
        </div>
846
        <button class="qj-btn qj-next" title="${esc(CONFIG.TEXT.NEXT_TITLE)}">${esc(CONFIG.TEXT.NEXT_BUTTON)}</button>
847
      </div>
848
      <div class="qj-toggles">
849
        <label title="${esc(CONFIG.TEXT.ONLY_NOTES_TITLE)}">
850
          <input type="checkbox" class="qj-toggle-onlynotes" checked> ${esc(CONFIG.TEXT.ONLY_NOTES_LABEL)}
851
        </label>
852
        <label title="${esc(longTitle)}">
853
          <input type="checkbox" class="qj-toggle-long"> ${esc(CONFIG.TEXT.ONLY_LONG_LABEL)}
854
        </label>
855
        <input type="text" class="qj-filter" placeholder="${esc(CONFIG.TEXT.FILTER_PLACEHOLDER)}">
856
      </div>
857
      <div class="qj-pills" role="listbox" aria-label="${esc(CONFIG.TEXT.PILLS_ARIA_LABEL)}"></div>
858
      <div class="qj-help">${CONFIG.TEXT.HELP_HTML}</div>
859
    `;
860
    document.body.appendChild(panel);
861

    
862
    const preview = document.createElement('div'); preview.id = PREVIEW_ID; document.body.appendChild(preview);
863
    const hint = document.createElement('div'); hint.id = HINT_ID; document.body.appendChild(hint);
864

    
865
    // Initialize lazy load indicator references now that panel exists
866
    loadIndicator = by('.qj-load-indicator', panel);
867
    loadProgress = loadIndicator?.querySelector('.qj-load-progress') || null;
868
    loadLabelEl = loadIndicator?.querySelector('.qj-load-label') || null;
869

    
870
    // Apply effective background color from nearest ancestor to CSS variable
871
    const baseBg = findEffectiveBackgroundColor(historyRoot);
872
    document.documentElement.style.setProperty('--qj-bg', baseBg);
873

    
874
    const slider = by('.qj-slider', panel);
875
    const btnPrev = by('.qj-prev', panel);
876
    const btnNext = by('.qj-next', panel);
877
    const counter = by('.qj-counter', panel);
878
    const pills = by('.qj-pills', panel);
879
    const closeBtn = by('.qj-close', panel);
880
    const toggleOnlyNotes = by('.qj-toggle-onlynotes', panel);
881
    const toggleLong = by('.qj-toggle-long', panel);
882
    const filterInput = by('.qj-filter', panel);
883

    
884
    let filteredIdxList = [];
885
    let pillEls = [];
886

    
887
    function formatLabel(n) {
888
      const when = n.when ? n.when.replace(/\s+/g, ' ').trim() : '—';
889
      const numTxt = isNaN(n.num) ? n.linkTxt : `#${n.num}`;
890
      return `${numTxt} · ${when} · ${n.author}`;
891
    }
892

    
893
    function buildPreviewHTML(n) {
894
      const useTitle = !!n.summary;
895
      const ttl = useTitle ? esc(n.summary) : '';
896
      const text = esc(n.fullText.slice(0, CONFIG.PREVIEW_MAX_CHARS));
897
      const textHTML = `<div class="txt">${text}${n.fullText.length > CONFIG.PREVIEW_MAX_CHARS ? '…' : ''}</div>`;
898
      return n.imgSrc
899
        ? `<div class="row"><img src="${n.imgSrc}"><div style="flex:1 1 auto;">${useTitle ? `<div class="ttl">${ttl}</div>` : ''}${textHTML}</div></div>`
900
        : `${useTitle ? `<div class="ttl">${ttl}</div>` : ''}${textHTML}`;
901
    }
902

    
903
    function showHint(e, text) {
904
      hint.textContent = text;
905
      hint.style.display = 'block';
906
      const rectW = hint.offsetWidth || 120;
907
      const x = Math.min(window.innerWidth - rectW - 8, Math.max(8, e.clientX - rectW / 2));
908
      const y = Math.max(8, e.clientY - 26);
909
      hint.style.left = `${x}px`;
910
      hint.style.top = `${y}px`;
911
    }
912

    
913
    function hideHint() { hint.style.display = 'none'; }
914

    
915
    function attachPillPreview(pill, n) {
916
      pill.removeAttribute('title');
917
      pill.addEventListener('mouseenter', (e) => {
918
        preview.innerHTML = buildPreviewHTML(n);
919
        preview.style.display = 'block';
920
        movePreview(e);
921
        const label = `${CONFIG.TEXT.HINT_JUMP_PREFIX}${isNaN(n.num) ? n.linkTxt : ('#' + n.num)}`;
922
        showHint(e, label);
923
      });
924
      pill.addEventListener('mousemove', (e) => { movePreview(e); showHint(e, hint.textContent); });
925
      pill.addEventListener('mouseleave', () => { preview.style.display = 'none'; hideHint(); });
926

    
927
      function movePreview(e) {
928
        const pad = 12;
929
        const vw = window.innerWidth, vh = window.innerHeight;
930
        const rectW = preview.offsetWidth || 320;
931
        const rectH = preview.offsetHeight || 140;
932

    
933
        let x;
934
        let y = e.clientY + pad;
935

    
936
        // Preview appears on the opposite side of the panel
937
        if (side === 'right') {
938
          x = e.clientX - pad - rectW;
939
        } else {
940
          x = e.clientX + pad;
941
        }
942

    
943
        if (x < 8) x = 8;
944
        if (x + rectW + 8 > vw) x = vw - rectW - 8;
945
        if (y + rectH + 8 > vh) y = vh - rectH - 8;
946
        if (y < 8) y = 8;
947

    
948
        preview.style.left = `${x}px`;
949
        preview.style.top = `${y}px`;
950
      }
951
    }
952

    
953
    function rebuildPills() {
954
      pills.innerHTML = '';
955
      pillEls = [];
956
      const term = filterInput.value.trim().toLowerCase();
957
      const useFilter = !!term;
958
      filteredIdxList = [];
959

    
960
      notes.forEach((n, idx) => {
961
        if (toggleLong.checked && n.textLen < CONFIG.LONG_NOTES_CHARACTER_THRESHOLD) return;
962
        if (useFilter) {
963
          const hay = `${n.author} ${n.num} ${n.linkTxt} ${n.when}`.toLowerCase();
964
          if (!hay.includes(term)) return;
965
        }
966
        filteredIdxList.push(idx);
967

    
968
        const pill = document.createElement('button');
969
        pill.className = 'qj-pill';
970
        pill.setAttribute('role', 'option');
971
        pill.textContent = formatLabel(n);
972
        pill.addEventListener('click', () => { setActiveIndex(idx); jumpTo(n.el); });
973
        attachPillPreview(pill, n);
974
        pills.appendChild(pill);
975
        pillEls[idx] = pill;
976
      });
977

    
978
      if (!filteredIdxList.length) {
979
        const none = document.createElement('div');
980
        none.style.opacity = '0.7';
981
        none.style.fontSize = '0.9em';
982
        none.textContent = CONFIG.TEXT.NO_RESULTS;
983
        pills.appendChild(none);
984
      }
985

    
986
      if (notes.length) {
987
        setActiveIndex(Math.min(activeIndex, notes.length - 1));
988
      } else {
989
        counter.textContent = '0/0';
990
        slider.max = '0';
991
        slider.value = '0';
992
      }
993
    }
994

    
995
    // Returns the current "active" index list (filtered or full)
996
    function getActiveIndexList() {
997
      const hasFilter =
998
        toggleLong.checked || (filterInput.value.trim().length > 0);
999

    
1000
      if (!hasFilter) {
1001
        return notes.map((_, i) => i);
1002
      }
1003

    
1004
      if (filteredIdxList && filteredIdxList.length) {
1005
        return filteredIdxList.slice();
1006
      }
1007
      return [];
1008
    }
1009

    
1010
    function ensureActivePillVisible() {
1011
      // Do nothing if the panel is closed → no internal scrolling when the panel is not visible
1012
      if (!panel.classList.contains('open')) return;
1013

    
1014
      const pill = pillEls[activeIndex];
1015
      if (!pill) return;
1016

    
1017
      pill.classList.add('active');
1018
      pillEls.forEach((p, i) => {
1019
        if (p && i !== activeIndex) p.classList.remove('active');
1020
      });
1021
      pillEls.forEach((p, i) => {
1022
        if (p) p.setAttribute('aria-selected', i === activeIndex ? 'true' : 'false');
1023
      });
1024

    
1025
      if (pill.scrollIntoView) {
1026
        pill.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1027
      } else {
1028
        const c = pills;
1029
        const pTop = pill.offsetTop;
1030
        const pBottom = pTop + pill.offsetHeight + 4;
1031
        if (pTop < c.scrollTop) c.scrollTop = pTop - 4;
1032
        else if (pBottom > c.scrollTop + c.clientHeight) c.scrollTop = pBottom - c.clientHeight;
1033
      }
1034
    }
1035

    
1036
    function setActiveIndex(noteIdx) {
1037
      if (!notes.length) return;
1038
      const maxIdx = notes.length - 1;
1039
      activeIndex = Math.max(0, Math.min(maxIdx, noteIdx));
1040

    
1041
      const activeList = getActiveIndexList();
1042
      if (!activeList.length) {
1043
        slider.max = '0';
1044
        slider.value = '0';
1045
        counter.textContent = `0/0`;
1046
        return;
1047
      }
1048

    
1049
      let logicalIndex = activeList.indexOf(activeIndex);
1050
      if (logicalIndex === -1) {
1051
        let nearest = activeList[0];
1052
        let best = Math.abs(nearest - activeIndex);
1053
        for (const idx of activeList) {
1054
          const d = Math.abs(idx - activeIndex);
1055
          if (d < best) { best = d; nearest = idx; }
1056
        }
1057
        activeIndex = nearest;
1058
        logicalIndex = activeList.indexOf(activeIndex);
1059
      }
1060

    
1061
      slider.max = String(Math.max(0, activeList.length - 1));
1062
      slider.value = String(Math.max(0, logicalIndex));
1063
      counter.textContent = `${logicalIndex + 1}/${activeList.length}`;
1064

    
1065
      ensureActivePillVisible();
1066
    }
1067

    
1068
    function rebuildAll() {
1069
      notes = collectNotes(excludeHidden, onlyNotes);
1070
      rebuildPills();
1071
    }
1072

    
1073
    function move(delta) {
1074
      const activeList = getActiveIndexList();
1075
      if (!activeList.length) return;
1076

    
1077
      const currPos = activeIndex === -1 ? -1 : activeList.indexOf(activeIndex);
1078
      const currentLogical = currPos === -1 ? 0 : currPos;
1079
      const nextPos = Math.max(0, Math.min(activeList.length - 1, currentLogical + delta));
1080
      const nextIdx = activeList[nextPos];
1081
      setActiveIndex(nextIdx);
1082
      const n = notes[activeIndex];
1083
      if (n) jumpTo(n.el);
1084
    }
1085

    
1086
    const sliderInput = () => {
1087
      const activeList = getActiveIndexList();
1088
      if (!activeList.length) return;
1089
      const logical = Math.max(0, Math.min(activeList.length - 1, parseInt(slider.value, 10) || 0));
1090
      const idx = activeList[logical];
1091
      setActiveIndex(idx);
1092
    };
1093

    
1094
    slider.addEventListener('input', sliderInput);
1095
    slider.addEventListener('change', () => {
1096
      const n = notes[activeIndex];
1097
      if (n) jumpTo(n.el);
1098
    });
1099
    btnPrev.addEventListener('click', () => move(-1));
1100
    btnNext.addEventListener('click', () => move(1));
1101

    
1102
    toggleOnlyNotes.addEventListener('change', () => {
1103
      onlyNotes = toggleOnlyNotes.checked;
1104
      // "Only notes" also ignores hidden journals
1105
      excludeHidden = toggleOnlyNotes.checked;
1106
      rebuildAll();
1107
    });
1108

    
1109
    toggleLong.addEventListener('change', rebuildPills);
1110
    filterInput.addEventListener('input', rebuildPills);
1111
    closeBtn.addEventListener('click', () => {
1112
      panel.classList.remove('open');
1113
      cancelLazyScroll();
1114
    });
1115

    
1116
    const launcherClick = () => {
1117
      const willOpen = !panel.classList.contains('open');
1118
      panel.classList.toggle('open');
1119

    
1120
      if (!panel.classList.contains('open')) {
1121
        // We just closed
1122
        cancelLazyScroll();
1123
        return;
1124
      }
1125

    
1126
      // Panel is being opened
1127
      if (willOpen && !CONFIG.OVERRIDE_DEFAULT_LAZY_LOAD) {
1128
        // Load history in steps and show progress indicator
1129
        loadAllLazyHistory(() => {
1130
          if (notes.length) selectClosest(false);
1131
        });
1132
      } else if (willOpen) {
1133
        // Default behavior: no auto-load
1134
        if (notes.length) selectClosest(false);
1135
      }
1136
    };
1137
    launcher.addEventListener('click', launcherClick);
1138

    
1139
    const onKey = (e) => {
1140
      if (!panel.classList.contains('open')) return;
1141
      const tag = (e.target.tagName || '').toLowerCase();
1142
      const isTyping = (tag === 'input' || tag === 'textarea' || tag === 'select');
1143
      if (isTyping && e.target !== filterInput) return;
1144
      if (e.key === 'j' || e.key === 'n') { e.preventDefault(); move(1); }
1145
      else if (e.key === 'k' || e.key === 'p') { e.preventDefault(); move(-1); }
1146
      else if (e.key.toLowerCase() === 'g') {
1147
        e.preventDefault();
1148
        const s = prompt(CONFIG.TEXT.PROMPT_GOTO); if (!s) return;
1149
        const targetNum = parseInt(s, 10); if (isNaN(targetNum)) return;
1150
        const idx = notes.findIndex(n => n.num === targetNum);
1151
        if (idx >= 0) { setActiveIndex(idx); jumpTo(notes[idx].el); }
1152
        else alert(CONFIG.TEXT.ALERT_GOTO_NOT_FOUND_PREFIX + targetNum);
1153
      }
1154
    };
1155
    window.addEventListener('keydown', onKey);
1156

    
1157
    function selectClosest(suppressHighlight = false) {
1158
      if (!notes.length) return;
1159
      const viewportMid = window.scrollY + window.innerHeight / 2;
1160
      let bestIdx = 0, bestDist = Infinity;
1161
      notes.forEach((n, i) => {
1162
        const rect = n.el.getBoundingClientRect();
1163
        const y = window.scrollY + rect.top;
1164
        const d = Math.abs(y - viewportMid);
1165
        if (d < bestDist) { bestDist = d; bestIdx = i; }
1166
      });
1167
      setActiveIndex(bestIdx);
1168
      highlight(notes[bestIdx].el, suppressHighlight);
1169
    }
1170

    
1171
    // Initial build
1172
    rebuildAll();
1173
    if (notes.length) {
1174
      selectClosest(true);
1175
    }
1176

    
1177
    const mo = new MutationObserver(() => {
1178
      clearTimeout(mo._t);
1179
      mo._t = setTimeout(() => {
1180
        const now = collectNotes(excludeHidden, onlyNotes);
1181
        const beforeIds = notes.map(n => n.el.id).join(',');
1182
        const afterIds = now.map(n => n.el.id).join(',');
1183
        if (beforeIds !== afterIds) {
1184
          notes = now;
1185
          rebuildPills();
1186
        }
1187
      }, 200);
1188
    });
1189
    mo.observe(historyRoot, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
1190

    
1191
    window.__QJ_HistoryTimeline = {
1192
      refresh: rebuildAll,
1193
      open: (forceLoad) => {
1194
        const wasClosed = !panel.classList.contains('open');
1195
        panel.classList.add('open');
1196

    
1197
        if (!wasClosed) return;
1198

    
1199
        if ((forceLoad || !CONFIG.OVERRIDE_DEFAULT_LAZY_LOAD)) {
1200
          loadAllLazyHistory(() => {
1201
            if (notes.length) selectClosest(false);
1202
          });
1203
        } else {
1204
          if (notes.length) selectClosest(false);
1205
        }
1206
      },
1207
      close: () => {
1208
        panel.classList.remove('open');
1209
        cancelLazyScroll();
1210
      },
1211
      setWidth: (px) => {
1212
        document.documentElement.style.setProperty('--qj-width', px + 'px');
1213
      },
1214
      setHeight: (px) => {
1215
        document.documentElement.style.setProperty('--qj-height', px + 'px');
1216
      },
1217
      setLeft: (px) => {
1218
        document.documentElement.style.setProperty('--qj-side-offset', px + 'px');
1219
      },
1220
      setBottom: (px) => {
1221
        document.documentElement.style.setProperty('--qj-bottom-quick', px + 'px');
1222
        document.documentElement.style.setProperty('--qj-bottom-panel', px + 'px');
1223
      },
1224
      setBottomQuick: (px) => {
1225
        document.documentElement.style.setProperty('--qj-bottom-quick', px + 'px');
1226
      },
1227
      setBottomPanel: (px) => {
1228
        document.documentElement.style.setProperty('--qj-bottom-panel', px + 'px');
1229
      },
1230
      setSide: (sideVal) => {
1231
        const isRight = (String(sideVal).toLowerCase() === 'right');
1232
        panel.classList.toggle('side-right', isRight);
1233
        panel.classList.toggle('side-left', !isRight);
1234
      },
1235
      setPillRadius: (px) => {
1236
        const styleEl = document.getElementById(STYLE_ID);
1237
        if (!styleEl) return;
1238
        styleEl.textContent = styleEl.textContent.replace(
1239
          /border-radius:\s*\d+px;(?=.*\.qj-pill)/,
1240
          `border-radius: ${px}px;`
1241
        );
1242
      },
1243
      destroy: () => {
1244
        mo.disconnect();
1245
        [panel, launcher, hoverZone, css, preview, hint].forEach(el => el?.remove());
1246
        window.removeEventListener('keydown', onKey);
1247
        window.removeEventListener('wheel', userOverrideScroll);
1248
        window.removeEventListener('touchmove', userOverrideScroll);
1249
        window.removeEventListener('keydown', userScrollKeyHandler);
1250
      }
1251
    };
1252
  }
1253
})();
(10-10/10)