Project

General

Profile

Feature #43462 » vanilla_styling_and_more_settings.js.txt

Jimmy Westberg, 2025-11-13 09:01

 
1
// Redmine History Timeline Navigator — Quick Jump (v1.9.10)
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 / 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

    
20
(function () {
21
  const CONFIG = {
22
    PANEL_WIDTH: 280,
23
    PANEL_HEIGHT: 380,
24

    
25
    // 'left' | 'right'
26
    SIDE: 'left',
27

    
28
    // Horizontal offset from chosen side
29
    SIDE_OFFSET: 6,
30

    
31
    // Bottom offsets
32
    BOTTOM_OFFSET_QUICK: 60,  // Quick Jump button / hover zone
33
    BOTTOM_OFFSET_PANEL: 60,  // Panel
34

    
35
    // Pill appearance
36
    PILL_RADIUS: 999,         // px, e.g. 999 for capsule, 6 for rounded
37

    
38
    // Threshold for "long notes" (characters)
39
    LONG_NOTES_CHARACTER_THRESHOLD: 500,
40

    
41
    NOTE_TARGET_PAD: 80,
42
    PREVIEW_MAX_CHARS: 220,
43
    PREVIEW_IMG_MAX_W: 160,
44
    PREVIEW_IMG_MAX_H: 100,
45

    
46
    // Highlight colors
47
    HIGHLIGHT_COLOR: 'rgba(37,99,235,0.85)',
48
    HIGHLIGHT_SOFT_COLOR: 'rgba(37,99,235,0.6)',
49
    HIGHLIGHT_FADE_MS: 700,
50

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

    
59
    // All user-visible text (except console)
60
    TEXT: {
61
      LAUNCHER_LABEL: 'Quick jump',
62
      PANEL_TITLE: 'History timeline',
63
      CLOSE_LABEL: 'Close',
64
      CLOSE_TITLE: 'Close',
65
      PREV_BUTTON: '⟨',
66
      PREV_TITLE: 'Previous (p/k)',
67
      NEXT_BUTTON: '⟩',
68
      NEXT_TITLE: 'Next (n/j)',
69
      EDGE_OLDEST: 'Oldest',
70
      EDGE_NEWEST: 'Newest',
71
      ONLY_NOTES_LABEL: 'Only notes',
72
      ONLY_NOTES_TITLE: 'Checked = show only journals with notes (has-notes) and ignore hidden journals. Unchecked = include all entries.',
73
      ONLY_LONG_LABEL: 'Only jump to long notes',
74
      FILTER_PLACEHOLDER: 'Filter: author, date or #',
75
      PILLS_ARIA_LABEL: 'Quick jump',
76
      HELP_HTML: 'Keyboard shortcuts:<br><b>n/j</b> next, <b>p/k</b> previous, <b>g</b> go to #',
77
      NO_RESULTS: 'No results match the filter.',
78
      PROMPT_GOTO: 'Go to comment # (e.g. 10):',
79
      ALERT_GOTO_NOT_FOUND_PREFIX: 'Could not find #',
80
      HINT_JUMP_PREFIX: 'Jump to '
81
    }
82
  };
83

    
84
  const HISTORY_SEL = '#tab-content-history';
85
  // const NOTE_SEL = '.note[id^="note-"]';  // Redmine 6
86
  const NOTE_SEL = 'div[id^="note-"]';       // older Redmine
87
  const JOURNAL_SEL = '.journal';
88

    
89
  const PANEL_ID = 'qj-history-timeline-panel';
90
  const STYLE_ID = PANEL_ID + '-style';
91
  const LAUNCH_ID = 'qj-history-launcher';
92
  const HOVERZONE_ID = 'qj-history-hoverzone';
93
  const PREVIEW_ID = 'qj-history-preview';
94
  const HINT_ID = 'qj-history-hint';
95
  const NOTE_HL_CLASS = 'qj-note-highlight';
96

    
97
  if (window.__QJ_HistoryTimeline?.destroy) {
98
    try { window.__QJ_HistoryTimeline.destroy(); } catch (e) {}
99
  }
100

    
101
  function ready(fn) {
102
    if (document.readyState === 'complete' || document.readyState === 'interactive') {
103
      setTimeout(fn, 0);
104
    } else {
105
      document.addEventListener('DOMContentLoaded', fn, { once: true });
106
    }
107
  }
108

    
109
  ready(bootstrap);
110

    
111
  let retryTimer = null;
112
  function bootstrap() {
113
    const tryInit = () => {
114
      const historyRoot = document.querySelector(HISTORY_SEL);
115
      if (!historyRoot) return false;
116
      init(historyRoot);
117
      return true;
118
    };
119
    if (!tryInit()) {
120
      let tries = 0;
121
      retryTimer = setInterval(() => {
122
        tries++;
123
        if (tryInit() || tries > 50) { clearInterval(retryTimer); retryTimer = null; }
124
      }, 200);
125
    }
126
  }
127

    
128
  // Find closest non-transparent background color starting from a given element.
129
  function findEffectiveBackgroundColor(rootEl) {
130
    const isTransparent = (bg) => {
131
      if (!bg) return true;
132
      bg = bg.trim().toLowerCase();
133
      if (bg === 'transparent') return true;
134
      return /^rgba?\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\)$/.test(bg);
135
    };
136

    
137
    let el = rootEl;
138
    while (el) {
139
      const cs = window.getComputedStyle(el);
140
      const bg = cs && cs.backgroundColor;
141
      if (!isTransparent(bg)) return bg;
142
      el = el.parentElement;
143
    }
144

    
145
    const htmlBg = window.getComputedStyle(document.documentElement).backgroundColor;
146
    if (!isTransparent(htmlBg)) return htmlBg;
147

    
148
    const bodyBg = window.getComputedStyle(document.body).backgroundColor;
149
    if (!isTransparent(bodyBg)) return bodyBg;
150

    
151
    return '#ffffff';
152
  }
153

    
154
  function init(historyRoot) {
155
    [PANEL_ID, STYLE_ID, LAUNCH_ID, HOVERZONE_ID, PREVIEW_ID, HINT_ID].forEach(id => document.getElementById(id)?.remove());
156
    document.querySelectorAll('.' + NOTE_HL_CLASS).forEach(el => el.classList.remove(NOTE_HL_CLASS));
157

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

    
161
    function jumpTo(el) {
162
      if (!el) return;
163
      const journal = el.closest(JOURNAL_SEL);
164
      if (journal && journal.style && journal.style.display === 'none') journal.style.display = '';
165
      const rect = el.getBoundingClientRect();
166
      const y = window.scrollY + rect.top - CONFIG.NOTE_TARGET_PAD;
167
      window.scrollTo({ top: Math.max(0, y), behavior: 'smooth' });
168
      highlight(el);
169
      setActiveIndex(indexOf(el));
170
    }
171

    
172
    const HIGHLIGHT_RING = '0 0 0 2px ' + CONFIG.HIGHLIGHT_COLOR;
173

    
174
    function highlight(el, silent = false) {
175
      document.querySelectorAll('.' + NOTE_HL_CLASS).forEach(e => e.classList.remove(NOTE_HL_CLASS));
176
      if (silent) return;
177
      el.classList.add(NOTE_HL_CLASS);
178
      el.style.transition = 'box-shadow 0.6s ease';
179
      el.style.boxShadow = HIGHLIGHT_RING;
180
      setTimeout(() => {
181
        el.style.boxShadow = '0 0 0 0 transparent';
182
      }, CONFIG.HIGHLIGHT_FADE_MS);
183
    }
184

    
185
    function extractWhenFromHeader(header) {
186
      if (!header) return '';
187
      const links = Array.from(header.querySelectorAll('a[title]'));
188
      const dateLike = links.find(a => /\d{4}-\d{2}-\d{2}/.test(a.getAttribute('title') || ''));
189
      if (dateLike) return dateLike.getAttribute('title') || dateLike.textContent.trim();
190
      const act = links.find(a => /activity\?from=/.test(a.getAttribute('href') || ''));
191
      if (act) return act.getAttribute('title') || act.textContent.trim();
192
      const last = links[links.length - 1];
193
      return last ? (last.getAttribute('title') || last.textContent.trim()) : '';
194
    }
195

    
196
    function getSummaryAndBody(wikiEl) {
197
      const firstHeading = wikiEl.querySelector('h1,h2,h3,h4,h5,h6');
198
      const headingText = firstHeading?.innerText?.trim() || '';
199
      let bodyText = (wikiEl.innerText || '').replace(/\s+/g, ' ').trim();
200
      const norm = s => (s || '').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, ' ').trim();
201
      if (headingText && norm(bodyText).startsWith(norm(headingText))) {
202
        const idx = bodyText.toLowerCase().indexOf(headingText.toLowerCase());
203
        if (idx === 0) bodyText = bodyText.slice(headingText.length).trim();
204
      }
205
      const showTitle = !!headingText && !norm(bodyText.slice(0, 100)).includes(norm(headingText));
206
      const summary = showTitle ? headingText : '';
207
      return { summary, bodyText };
208
    }
209

    
210
    const collectNotes = (excludeHiddenJournals, onlyNotesFlag) => {
211
      const notes = Array.from(historyRoot.querySelectorAll(NOTE_SEL))
212
        .filter(n => {
213
          const j = n.closest(JOURNAL_SEL);
214

    
215
          // Hide hidden journals if requested
216
          if (excludeHiddenJournals && j && j.style && j.style.display === 'none') {
217
            return false;
218
          }
219

    
220
          // If we only want "real notes":
221
          // skip journals that are only .has-details and NOT .has-notes
222
          if (onlyNotesFlag && j) {
223
            const jc = j.classList;
224
            if (jc.contains('has-details') && !jc.contains('has-notes')) {
225
              return false;
226
            }
227
          }
228

    
229
          return true;
230
        })
231
        .sort((a, b) => {
232
          const an = parseInt(a.id.replace('note-', ''), 10);
233
          const bn = parseInt(b.id.replace('note-', ''), 10);
234
          if (!isNaN(an) && !isNaN(bn)) return an - bn;
235
          return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
236
        });
237

    
238
      return notes.map((note, i) => {
239
        const num = parseInt(note.id.replace('note-', ''), 10);
240
        const journal = note.closest(JOURNAL_SEL);
241
        const header = note.querySelector('.note-header') || journal?.querySelector('.note-header');
242
        const author = header?.querySelector('a.user')?.textContent?.trim() || 'Unknown';
243
        const linkTxt = journal?.querySelector('.journal-link')?.textContent?.trim() || ('#' + (isNaN(num) ? (i + 1) : num));
244
        const when = extractWhenFromHeader(header);
245
        const wiki = note.querySelector('.wiki') || note;
246
        const { summary, bodyText } = getSummaryAndBody(wiki);
247
        const imgSrc = (wiki.querySelector('img')?.getAttribute('src')) || null;
248
        return { el: note, num, author, linkTxt, when, textLen: bodyText.length, imgSrc, summary, fullText: bodyText };
249
      });
250
    };
251

    
252
    let excludeHidden = true;
253
    let onlyNotes = true;
254
    let notes = collectNotes(excludeHidden, onlyNotes);
255
    if (!notes.length) { console.warn('QuickJump: No notes found.'); return; }
256
    const indexOf = (el) => notes.findIndex(n => n.el === el);
257
    let activeIndex = 0;
258

    
259
    const bottomQuick = CONFIG.BOTTOM_OFFSET_QUICK ?? CONFIG.BOTTOM_OFFSET ?? 60;
260
    const bottomPanel = CONFIG.BOTTOM_OFFSET_PANEL ?? CONFIG.BOTTOM_OFFSET ?? bottomQuick;
261
    const sideOffset = CONFIG.SIDE_OFFSET ?? 6;
262
    const side = (CONFIG.SIDE === 'right') ? 'right' : 'left';
263

    
264
    const zBase = CONFIG.ZINDEX_BASE ?? 9990;
265
    const zHover = zBase;
266
    const zLaunch = zBase + 1;
267
    const zPanel = zBase + 2;
268
    const zPreview = zBase + 3;
269
    const zHint = zBase + 4;
270

    
271
    const css = document.createElement('style');
272
    css.id = STYLE_ID;
273
    css.textContent = `
274
      :root {
275
        --qj-width: ${CONFIG.PANEL_WIDTH}px;
276
        --qj-height: ${CONFIG.PANEL_HEIGHT}px;
277
        --qj-bottom-quick: ${bottomQuick}px;
278
        --qj-bottom-panel: ${bottomPanel}px;
279
        --qj-side-offset: ${sideOffset}px;
280
        --qj-bg: #ffffff;
281
        --qj-highlight: ${CONFIG.HIGHLIGHT_COLOR};
282
        --qj-highlight-soft: ${CONFIG.HIGHLIGHT_SOFT_COLOR};
283
      }
284
      #${HOVERZONE_ID} {
285
        position: fixed;
286
        left: 50%;
287
        transform: translateX(-50%);
288
        bottom: var(--qj-bottom-quick);
289
        width: 360px;
290
        height: 42px;
291
        z-index: ${zHover};
292
      }
293
      #${LAUNCH_ID} {
294
        position: fixed;
295
        left: 50%;
296
        transform: translateX(-50%);
297
        bottom: calc(var(--qj-bottom-quick) - 10px);
298
        z-index: ${zLaunch};
299
        opacity: 0;
300
        transition: opacity .2s ease, transform .2s ease;
301
        cursor: pointer;
302
        padding: 4px 10px;
303
        font-size: 0.95em;
304
        white-space: nowrap;
305
      }
306
      #${HOVERZONE_ID}:hover + #${LAUNCH_ID},
307
      #${LAUNCH_ID}:hover {
308
        opacity: 1;
309
        transform: translateX(-50%) translateY(-2px);
310
      }
311
      #${PANEL_ID} {
312
        position: fixed;
313
        width: var(--qj-width);
314
        height: var(--qj-height);
315
        bottom: calc(var(--qj-bottom-panel) + 14px);
316
        max-width: calc(100vw - 24px);
317
        border: 1px solid rgba(0,0,0,0.12);
318
        border-radius: 10px;
319
        box-shadow: 0 8px 24px rgba(0,0,0,0.15);
320
        overflow: hidden;
321
        user-select: none;
322
        z-index: ${zPanel};
323
        opacity: 0;
324
        pointer-events: none;
325
        transition: transform .2s ease, opacity .2s ease;
326
        background-color: var(--qj-bg);
327
        display: flex;
328
        flex-direction: column;
329
      }
330
      #${PANEL_ID}.side-left {
331
        left: var(--qj-side-offset);
332
        right: auto;
333
        transform: translate(-120%, 0);
334
      }
335
      #${PANEL_ID}.side-right {
336
        right: var(--qj-side-offset);
337
        left: auto;
338
        transform: translate(120%, 0);
339
      }
340
      #${PANEL_ID}.open {
341
        transform: translate(0, 0);
342
        opacity: 1;
343
        pointer-events: auto;
344
      }
345
      #${PANEL_ID} .qj-hdr {
346
        display: flex;
347
        align-items: center;
348
        justify-content: space-between;
349
        padding: 6px 10px;
350
        border-bottom: 1px solid rgba(0,0,0,0.08);
351
        font-size: 0.95em;
352
      }
353
      #${PANEL_ID} .qj-title {
354
        font-weight: 600;
355
      }
356
      #${PANEL_ID} .qj-counter {
357
        opacity: 0.8;
358
        font-variant-numeric: tabular-nums;
359
      }
360
      #${PANEL_ID} .qj-close {
361
        font-size: 0.9em;
362
        padding: 2px 6px;
363
        cursor: pointer;
364
      }
365
      #${PANEL_ID} .qj-controls {
366
        display: grid;
367
        grid-template-columns: auto 1fr auto;
368
        gap: 6px;
369
        align-items: center;
370
        padding: 6px 10px;
371
        font-size: 0.95em;
372
      }
373
      #${PANEL_ID} .qj-btn {
374
        cursor: pointer;
375
        padding: 2px 6px;
376
        font-size: 0.95em;
377
      }
378
      #${PANEL_ID} .qj-slider-wrap {
379
        display: flex;
380
        align-items: center;
381
        gap: 6px;
382
      }
383
      #${PANEL_ID} .qj-edge {
384
        font-size: 0.85em;
385
        opacity: 0.8;
386
        user-select: none;
387
      }
388
      #${PANEL_ID} .qj-slider {
389
        -webkit-appearance: none;
390
        appearance: none;
391
        width: 100%;
392
        height: 16px;
393
        background: transparent;
394
      }
395
      #${PANEL_ID} .qj-slider::-webkit-slider-runnable-track {
396
        height: 3px;
397
        background: rgba(0,0,0,0.12);
398
        border-radius: 999px;
399
      }
400
      #${PANEL_ID} .qj-slider::-moz-range-track {
401
        height: 3px;
402
        background: rgba(0,0,0,0.12);
403
        border-radius: 999px;
404
      }
405
      #${PANEL_ID} .qj-slider::-webkit-slider-thumb {
406
        -webkit-appearance: none;
407
        width: 12px;
408
        height: 12px;
409
        border-radius: 50%;
410
        border: 1px solid rgba(0,0,0,0.3);
411
        margin-top: -5px;
412
        background-color: var(--qj-bg);
413
      }
414
      #${PANEL_ID} .qj-slider::-moz-range-thumb {
415
        width: 12px;
416
        height: 12px;
417
        border-radius: 50%;
418
        border: 1px solid rgba(0,0,0,0.3);
419
        background-color: var(--qj-bg);
420
      }
421
      #${PANEL_ID} .qj-toggles {
422
        display: flex;
423
        gap: 8px;
424
        align-items: center;
425
        padding: 0 10px 6px 10px;
426
        flex-wrap: wrap;
427
        font-size: 0.95em;
428
      }
429
      #${PANEL_ID} .qj-toggles label {
430
        display: flex;
431
        gap: 4px;
432
        align-items: center;
433
      }
434
      #${PANEL_ID} .qj-filter {
435
        flex: 1;
436
        min-width: 120px;
437
        padding: 2px 6px;
438
        font-size: 0.95em;
439
      }
440
      #${PANEL_ID} .qj-pills {
441
        display: flex;
442
        flex-wrap: wrap;
443
        justify-content: flex-start;
444
        align-content: flex-start;
445
        gap: 4px;
446
        padding: 0 10px 8px 10px;
447
        overflow-y: auto;
448
        overflow-x: hidden;
449
        flex: 1 1 auto;
450
      }
451
      #${PANEL_ID} .qj-pill {
452
        font-size: 0.9em;
453
        padding: 2px 8px;
454
        border-radius: ${CONFIG.PILL_RADIUS}px;
455
        cursor: pointer;
456
        max-width: 100%;
457
        overflow: hidden;
458
        text-overflow: ellipsis;
459
        white-space: nowrap;
460
        border: 1px solid rgba(0,0,0,0.18);
461
      }
462
      #${PANEL_ID} .qj-pill.active {
463
        outline: 1px solid var(--qj-highlight-soft);
464
        outline-offset: 0;
465
      }
466
      #${PANEL_ID} .qj-help {
467
        font-size: 0.85em;
468
        opacity: 0.8;
469
        padding: 0 10px 8px 10px;
470
      }
471

    
472
      .${NOTE_HL_CLASS} { border-radius: inherit !important; }
473
      .${NOTE_HL_CLASS}::before { border-color: var(--qj-highlight) !important; }
474

    
475
      #${PREVIEW_ID} {
476
        position: fixed;
477
        z-index: ${zPreview};
478
        pointer-events: none;
479
        max-width: 340px;
480
        border: 1px solid rgba(0,0,0,0.12);
481
        border-radius: 8px;
482
        box-shadow: 0 8px 24px rgba(0,0,0,0.18);
483
        padding: 6px;
484
        font-size: 0.85em;
485
        display: none;
486
        background-color: var(--qj-bg);
487
      }
488
      #${PREVIEW_ID} .ttl { font-weight: 600; margin-bottom: 4px; }
489
      #${PREVIEW_ID} .txt { line-height: 1.3; }
490
      #${PREVIEW_ID} .row {
491
        display: flex;
492
        gap: 6px;
493
        align-items: flex-start;
494
      }
495
      #${PREVIEW_ID} img {
496
        display: block;
497
        max-width: ${CONFIG.PREVIEW_IMG_MAX_W}px;
498
        max-height: ${CONFIG.PREVIEW_IMG_MAX_H}px;
499
        border-radius: 4px;
500
        border: 1px solid rgba(0,0,0,0.1);
501
      }
502
      #${HINT_ID} {
503
        position: fixed;
504
        z-index: ${zHint};
505
        pointer-events: none;
506
        background: #111;
507
        color: #fff;
508
        border-radius: 4px;
509
        padding: 2px 6px;
510
        font-size: 0.8em;
511
        box-shadow: 0 6px 18px rgba(0,0,0,0.2);
512
        display: none;
513
        white-space: nowrap;
514
      }
515
    `;
516
    document.head.appendChild(css);
517

    
518
    const hoverZone = document.createElement('div'); hoverZone.id = HOVERZONE_ID;
519
    const launcher = document.createElement('button'); launcher.id = LAUNCH_ID; launcher.textContent = CONFIG.TEXT.LAUNCHER_LABEL;
520
    document.body.appendChild(hoverZone); document.body.appendChild(launcher);
521

    
522
    const panel = document.createElement('div');
523
    panel.id = PANEL_ID;
524
    panel.className = (side === 'right') ? 'side-right' : 'side-left';
525
    panel.innerHTML = `
526
      <div class="qj-hdr">
527
        <span class="qj-title">${esc(CONFIG.TEXT.PANEL_TITLE)}</span>
528
        <span class="qj-counter"></span>
529
        <button class="qj-close" title="${esc(CONFIG.TEXT.CLOSE_TITLE)}">${esc(CONFIG.TEXT.CLOSE_LABEL)}</button>
530
      </div>
531
      <div class="qj-controls">
532
        <button class="qj-btn qj-prev" title="${esc(CONFIG.TEXT.PREV_TITLE)}">${esc(CONFIG.TEXT.PREV_BUTTON)}</button>
533
        <div class="qj-slider-wrap">
534
          <span class="qj-edge">${esc(CONFIG.TEXT.EDGE_OLDEST)}</span>
535
          <input type="range" class="qj-slider" min="0" value="0" step="1">
536
          <span class="qj-edge">${esc(CONFIG.TEXT.EDGE_NEWEST)}</span>
537
        </div>
538
        <button class="qj-btn qj-next" title="${esc(CONFIG.TEXT.NEXT_TITLE)}">${esc(CONFIG.TEXT.NEXT_BUTTON)}</button>
539
      </div>
540
      <div class="qj-toggles">
541
        <label title="${esc(CONFIG.TEXT.ONLY_NOTES_TITLE)}">
542
          <input type="checkbox" class="qj-toggle-onlynotes" checked> ${esc(CONFIG.TEXT.ONLY_NOTES_LABEL)}
543
        </label>
544
        <label><input type="checkbox" class="qj-toggle-long"> ${esc(CONFIG.TEXT.ONLY_LONG_LABEL)}</label>
545
        <input type="text" class="qj-filter" placeholder="${esc(CONFIG.TEXT.FILTER_PLACEHOLDER)}">
546
      </div>
547
      <div class="qj-pills" role="listbox" aria-label="${esc(CONFIG.TEXT.PILLS_ARIA_LABEL)}"></div>
548
      <div class="qj-help">${CONFIG.TEXT.HELP_HTML}</div>
549
    `;
550
    document.body.appendChild(panel);
551

    
552
    const preview = document.createElement('div'); preview.id = PREVIEW_ID; document.body.appendChild(preview);
553
    const hint = document.createElement('div'); hint.id = HINT_ID; document.body.appendChild(hint);
554

    
555
    // Apply effective background color from nearest ancestor to CSS variable
556
    const baseBg = findEffectiveBackgroundColor(historyRoot);
557
    document.documentElement.style.setProperty('--qj-bg', baseBg);
558

    
559
    const slider = by('.qj-slider', panel);
560
    const btnPrev = by('.qj-prev', panel);
561
    const btnNext = by('.qj-next', panel);
562
    const counter = by('.qj-counter', panel);
563
    const pills = by('.qj-pills', panel);
564
    const closeBtn = by('.qj-close', panel);
565
    const toggleOnlyNotes = by('.qj-toggle-onlynotes', panel);
566
    const toggleLong = by('.qj-toggle-long', panel);
567
    const filterInput = by('.qj-filter', panel);
568

    
569
    let filteredIdxList = [];
570
    let pillEls = [];
571

    
572
    function formatLabel(n) {
573
      const when = n.when ? n.when.replace(/\s+/g, ' ').trim() : '—';
574
      const numTxt = isNaN(n.num) ? n.linkTxt : `#${n.num}`;
575
      return `${numTxt} · ${when} · ${n.author}`;
576
    }
577

    
578
    function buildPreviewHTML(n) {
579
      const useTitle = !!n.summary;
580
      const ttl = useTitle ? esc(n.summary) : '';
581
      const text = esc(n.fullText.slice(0, CONFIG.PREVIEW_MAX_CHARS));
582
      const textHTML = `<div class="txt">${text}${n.fullText.length > CONFIG.PREVIEW_MAX_CHARS ? '…' : ''}</div>`;
583
      return n.imgSrc
584
        ? `<div class="row"><img src="${n.imgSrc}"><div style="flex:1 1 auto;">${useTitle ? `<div class="ttl">${ttl}</div>` : ''}${textHTML}</div></div>`
585
        : `${useTitle ? `<div class="ttl">${ttl}</div>` : ''}${textHTML}`;
586
    }
587

    
588
    function showHint(e, text) {
589
      hint.textContent = text;
590
      hint.style.display = 'block';
591
      const rectW = hint.offsetWidth || 120;
592
      const x = Math.min(window.innerWidth - rectW - 8, Math.max(8, e.clientX - rectW / 2));
593
      const y = Math.max(8, e.clientY - 26);
594
      hint.style.left = `${x}px`;
595
      hint.style.top = `${y}px`;
596
    }
597

    
598
    function hideHint() { hint.style.display = 'none'; }
599

    
600
    function attachPillPreview(pill, n) {
601
      pill.removeAttribute('title');
602
      pill.addEventListener('mouseenter', (e) => {
603
        preview.innerHTML = buildPreviewHTML(n);
604
        preview.style.display = 'block';
605
        movePreview(e);
606
        const label = `${CONFIG.TEXT.HINT_JUMP_PREFIX}${isNaN(n.num) ? n.linkTxt : ('#' + n.num)}`;
607
        showHint(e, label);
608
      });
609
      pill.addEventListener('mousemove', (e) => { movePreview(e); showHint(e, hint.textContent); });
610
      pill.addEventListener('mouseleave', () => { preview.style.display = 'none'; hideHint(); });
611

    
612
      function movePreview(e) {
613
        const pad = 12;
614
        const vw = window.innerWidth, vh = window.innerHeight;
615
        const rectW = preview.offsetWidth || 320;
616
        const rectH = preview.offsetHeight || 140;
617

    
618
        let x;
619
        let y = e.clientY + pad;
620

    
621
        if (side === 'right') {
622
          x = e.clientX - pad - rectW;
623
        } else {
624
          x = e.clientX + pad;
625
        }
626

    
627
        if (x < 8) x = 8;
628
        if (x + rectW + 8 > vw) x = vw - rectW - 8;
629
        if (y + rectH + 8 > vh) y = vh - rectH - 8;
630
        if (y < 8) y = 8;
631

    
632
        preview.style.left = `${x}px`;
633
        preview.style.top = `${y}px`;
634
      }
635
    }
636

    
637
    function rebuildPills() {
638
      pills.innerHTML = '';
639
      pillEls = [];
640
      const term = filterInput.value.trim().toLowerCase();
641
      const useFilter = !!term;
642
      filteredIdxList = [];
643

    
644
      notes.forEach((n, idx) => {
645
        if (toggleLong.checked && n.textLen < CONFIG.LONG_NOTES_CHARACTER_THRESHOLD) return;
646
        if (useFilter) {
647
          const hay = `${n.author} ${n.num} ${n.linkTxt} ${n.when}`.toLowerCase();
648
          if (!hay.includes(term)) return;
649
        }
650
        filteredIdxList.push(idx);
651

    
652
        const pill = document.createElement('button');
653
        pill.className = 'qj-pill';
654
        pill.setAttribute('role', 'option');
655
        pill.textContent = formatLabel(n);
656
        pill.addEventListener('click', () => { setActiveIndex(idx); jumpTo(n.el); });
657
        attachPillPreview(pill, n);
658
        pills.appendChild(pill);
659
        pillEls[idx] = pill;
660
      });
661

    
662
      if (!filteredIdxList.length) {
663
        const none = document.createElement('div');
664
        none.style.opacity = '0.7';
665
        none.style.fontSize = '0.9em';
666
        none.textContent = CONFIG.TEXT.NO_RESULTS;
667
        pills.appendChild(none);
668
      }
669

    
670
      setActiveIndex(Math.min(activeIndex, notes.length - 1));
671
    }
672

    
673
    // Returns the current "active" index list (filtered or full)
674
    function getActiveIndexList() {
675
      const hasFilter =
676
        toggleLong.checked || (filterInput.value.trim().length > 0);
677

    
678
      if (!hasFilter) {
679
        return notes.map((_, i) => i);
680
      }
681

    
682
      if (filteredIdxList && filteredIdxList.length) {
683
        return filteredIdxList.slice();
684
      }
685
      return [];
686
    }
687

    
688
    function ensureActivePillVisible() {
689
      const pill = pillEls[activeIndex];
690
      if (!pill) return;
691

    
692
      pill.classList.add('active');
693
      pillEls.forEach((p, i) => { if (p && i !== activeIndex) p.classList.remove('active'); });
694
      pillEls.forEach((p, i) => p && p.setAttribute('aria-selected', i === activeIndex ? 'true' : 'false'));
695

    
696
      if (pill.scrollIntoView) {
697
        pill.scrollIntoView({ block: 'nearest', inline: 'nearest' });
698
      } else {
699
        const c = pills, pTop = pill.offsetTop, pBottom = pTop + pill.offsetHeight + 4;
700
        if (pTop < c.scrollTop) c.scrollTop = pTop - 4;
701
        else if (pBottom > c.scrollTop + c.clientHeight) c.scrollTop = pBottom - c.clientHeight;
702
      }
703
    }
704

    
705
    function setActiveIndex(noteIdx) {
706
      if (!notes.length) return;
707
      const maxIdx = notes.length - 1;
708
      activeIndex = Math.max(0, Math.min(maxIdx, noteIdx));
709

    
710
      const activeList = getActiveIndexList();
711
      if (!activeList.length) {
712
        slider.max = '0';
713
        slider.value = '0';
714
        counter.textContent = `0/0`;
715
        return;
716
      }
717

    
718
      let logicalIndex = activeList.indexOf(activeIndex);
719
      if (logicalIndex === -1) {
720
        let nearest = activeList[0];
721
        let best = Math.abs(nearest - activeIndex);
722
        for (const idx of activeList) {
723
          const d = Math.abs(idx - activeIndex);
724
          if (d < best) { best = d; nearest = idx; }
725
        }
726
        activeIndex = nearest;
727
        logicalIndex = activeList.indexOf(activeIndex);
728
      }
729

    
730
      slider.max = String(Math.max(0, activeList.length - 1));
731
      slider.value = String(Math.max(0, logicalIndex));
732
      counter.textContent = `${logicalIndex + 1}/${activeList.length}`;
733

    
734
      ensureActivePillVisible();
735
    }
736

    
737
    function rebuildAll() {
738
      notes = collectNotes(excludeHidden, onlyNotes);
739
      rebuildPills();
740
    }
741

    
742
    function move(delta) {
743
      const activeList = getActiveIndexList();
744
      if (!activeList.length) return;
745

    
746
      const currPos = activeIndex === -1 ? -1 : activeList.indexOf(activeIndex);
747
      const currentLogical = currPos === -1 ? 0 : currPos;
748
      const nextPos = Math.max(0, Math.min(activeList.length - 1, currentLogical + delta));
749
      const nextIdx = activeList[nextPos];
750
      setActiveIndex(nextIdx);
751
      const n = notes[activeIndex];
752
      if (n) jumpTo(n.el);
753
    }
754

    
755
    const sliderInput = () => {
756
      const activeList = getActiveIndexList();
757
      if (!activeList.length) return;
758
      const logical = Math.max(0, Math.min(activeList.length - 1, parseInt(slider.value, 10) || 0));
759
      const idx = activeList[logical];
760
      setActiveIndex(idx);
761
    };
762

    
763
    slider.addEventListener('input', sliderInput);
764
    slider.addEventListener('change', () => {
765
      const n = notes[activeIndex];
766
      if (n) jumpTo(n.el);
767
    });
768
    btnPrev.addEventListener('click', () => move(-1));
769
    btnNext.addEventListener('click', () => move(1));
770

    
771
    toggleOnlyNotes.addEventListener('change', () => {
772
      onlyNotes = toggleOnlyNotes.checked;
773
      excludeHidden = toggleOnlyNotes.checked; // "Only notes" also ignores hidden journals
774
      rebuildAll();
775
    });
776

    
777
    toggleLong.addEventListener('change', rebuildPills);
778
    filterInput.addEventListener('input', rebuildPills);
779
    closeBtn.addEventListener('click', () => panel.classList.remove('open'));
780

    
781
    const launcherClick = () => {
782
      panel.classList.toggle('open');
783
      if (panel.classList.contains('open')) selectClosest(false);
784
    };
785
    launcher.addEventListener('click', launcherClick);
786

    
787
    const onKey = (e) => {
788
      if (!panel.classList.contains('open')) return;
789
      const tag = (e.target.tagName || '').toLowerCase();
790
      const isTyping = (tag === 'input' || tag === 'textarea' || tag === 'select');
791
      if (isTyping && e.target !== filterInput) return;
792
      if (e.key === 'j' || e.key === 'n') { e.preventDefault(); move(1); }
793
      else if (e.key === 'k' || e.key === 'p') { e.preventDefault(); move(-1); }
794
      else if (e.key.toLowerCase() === 'g') {
795
        e.preventDefault();
796
        const s = prompt(CONFIG.TEXT.PROMPT_GOTO); if (!s) return;
797
        const targetNum = parseInt(s, 10); if (isNaN(targetNum)) return;
798
        const idx = notes.findIndex(n => n.num === targetNum);
799
        if (idx >= 0) { setActiveIndex(idx); jumpTo(notes[idx].el); }
800
        else alert(CONFIG.TEXT.ALERT_GOTO_NOT_FOUND_PREFIX + targetNum);
801
      }
802
    };
803
    window.addEventListener('keydown', onKey);
804

    
805
    function selectClosest(suppressHighlight = false) {
806
      const viewportMid = window.scrollY + window.innerHeight / 2;
807
      let bestIdx = 0, bestDist = Infinity;
808
      notes.forEach((n, i) => {
809
        const rect = n.el.getBoundingClientRect();
810
        const y = window.scrollY + rect.top;
811
        const d = Math.abs(y - viewportMid);
812
        if (d < bestDist) { bestDist = d; bestIdx = i; }
813
      });
814
      setActiveIndex(bestIdx);
815
      highlight(notes[bestIdx].el, suppressHighlight);
816
    }
817

    
818
    rebuildAll();
819
    selectClosest(true);
820

    
821
    const mo = new MutationObserver(() => {
822
      clearTimeout(mo._t);
823
      mo._t = setTimeout(() => {
824
        const now = collectNotes(excludeHidden, onlyNotes);
825
        const beforeIds = notes.map(n => n.el.id).join(',');
826
        const afterIds = now.map(n => n.el.id).join(',');
827
        if (beforeIds !== afterIds) { notes = now; rebuildPills(); }
828
      }, 200);
829
    });
830
    mo.observe(historyRoot, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
831

    
832
    window.__QJ_HistoryTimeline = {
833
      refresh: rebuildAll,
834
      open: () => panel.classList.add('open'),
835
      close: () => panel.classList.remove('open'),
836
      setWidth: (px) => {
837
        document.documentElement.style.setProperty('--qj-width', px + 'px');
838
      },
839
      setHeight: (px) => {
840
        document.documentElement.style.setProperty('--qj-height', px + 'px');
841
      },
842
      setLeft: (px) => {
843
        document.documentElement.style.setProperty('--qj-side-offset', px + 'px');
844
      },
845
      setBottom: (px) => {
846
        document.documentElement.style.setProperty('--qj-bottom-quick', px + 'px');
847
        document.documentElement.style.setProperty('--qj-bottom-panel', px + 'px');
848
      },
849
      setBottomQuick: (px) => {
850
        document.documentElement.style.setProperty('--qj-bottom-quick', px + 'px');
851
      },
852
      setBottomPanel: (px) => {
853
        document.documentElement.style.setProperty('--qj-bottom-panel', px + 'px');
854
      },
855
      setSide: (sideVal) => {
856
        const isRight = (String(sideVal).toLowerCase() === 'right');
857
        panel.classList.toggle('side-right', isRight);
858
        panel.classList.toggle('side-left', !isRight);
859
      },
860
      setPillRadius: (px) => {
861
        const styleEl = document.getElementById(STYLE_ID);
862
        if (!styleEl) return;
863
        styleEl.textContent = styleEl.textContent.replace(
864
          /border-radius:\s*\d+px;(?=.*\.qj-pill)/,
865
          `border-radius: ${px}px;`
866
        );
867
      },
868
      destroy: () => {
869
        mo.disconnect();
870
        [panel, launcher, hoverZone, css, preview, hint].forEach(el => el?.remove());
871
        window.removeEventListener('keydown', onKey);
872
      }
873
    };
874
  }
875
})();
(5-5/10)