/* ============================================= HELLION NEWTAB — palette.js Command-Palette (Strg+K): read-only Suche ueber Boards/Bookmarks ============================================= */ // Reine init-Funktion ohne Top-Level-Seiteneffekt (Hausmuster wie initSearch). // Wird aus app.js init() nach initSearch() aufgerufen. function initPalette() { let overlay = null; // aktives Overlay-Element oder null (= geschlossen) let prevFocus = null; // Fokus-Rueckgabeziel let keyHandler = null; // dokument-weiter Handler im offenen Zustand let results = []; // aktuelle Trefferliste [{ title, url, boardName }] let activeIndex = -1; // aktiver Listeneintrag fuer aria-activedescendant // ---- Open-Guard: kein Strg+K wenn ein anderes Overlay offen ist ---- // Deckt Settings (.panel-overlay), Theme/Add-Board/Add-Bookmark/Rename // (.modal-overlay) sowie HellionDialog UND Onboarding (.dialog-overlay) ab. function isBlocked() { // .palette-overlay ausklammern: das eigene (beim Schliessen noch deferred .active // tragende) Overlay darf den Reopen-Guard nicht selbst blockieren (Self-Block-Race // beim Toggle-Spam, da close() .active erst in withViewTransition entfernt). return !!document.querySelector( '.panel-overlay.active, .modal-overlay.active, .dialog-overlay.active:not(.palette-overlay)' ); } // ---- Trefferquelle: flach ueber alle Boards/Bookmarks ---- // Read-only auf dem globalen boards-Array. Match auf Titel, URL, Board-Name. function search(query) { const q = query.trim().toLowerCase(); if (!q) return []; const out = []; for (const board of boards) { const boardName = board.title || ''; const boardMatch = boardName.toLowerCase().includes(q); for (const bm of (board.bookmarks || [])) { const title = bm.title || ''; const url = bm.url || ''; if ( title.toLowerCase().includes(q) || url.toLowerCase().includes(q) || boardMatch ) { out.push({ title, url, boardName }); } } } return out; } // ---- Treffer oeffnen (wie boards.js:270) ---- function openResult(item) { if (!item || !item.url) return; // Sicherheit: nur sichere Protokolle oeffnen. Verhindert javascript:/data:-URLs aus // importierten Bookmarks (XSS im Extension-Origin, besonders bei _self). http/https/ftp only. let safe = false; try { safe = ['http:', 'https:', 'ftp:'].includes(new URL(item.url).protocol); } catch (e) { /* ungueltige URL */ } if (!safe) { close(); return; } window.open(item.url, settings.newTab ? '_blank' : '_self', 'noopener,noreferrer'); close(); } // ---- Listbox neu rendern ---- function renderList(listEl, liveEl) { listEl.textContent = ''; activeIndex = results.length ? 0 : -1; if (results.length === 0) { // Kein role="option": der Leerzustand ist keine auswaehlbare Option, sondern eine // Statuszeile. Die Ansage uebernimmt die aria-live-Region (liveEl) unten. const empty = document.createElement('li'); empty.className = 'palette-empty'; empty.setAttribute('role', 'presentation'); empty.textContent = t('palette.no_results'); listEl.appendChild(empty); liveEl.textContent = t('palette.no_results'); return; } results.forEach((item, i) => { const li = document.createElement('li'); li.className = 'palette-option'; li.id = 'palette-opt-' + i; li.setAttribute('role', 'option'); li.setAttribute('aria-selected', i === 0 ? 'true' : 'false'); const titleSpan = document.createElement('span'); titleSpan.className = 'palette-option-title'; titleSpan.textContent = item.title; const metaSpan = document.createElement('span'); metaSpan.className = 'palette-option-meta'; metaSpan.textContent = t('palette.board_prefix') + ' ' + item.boardName; li.append(titleSpan, metaSpan); // Pointer-Auswahl: Klick oeffnet, Hover markiert li.addEventListener('click', () => openResult(item)); li.addEventListener('mousemove', () => setActive(listEl, i)); listEl.appendChild(li); }); const count = results.length; liveEl.textContent = count === 1 ? t('palette.count_one') : t('palette.count', { count }); } // ---- aktiven Eintrag setzen (aria-activedescendant + aria-selected) ---- function setActive(listEl, idx) { // Guard: close() nullt overlay synchron, das DOM-Removal laeuft aber deferred in // withViewTransition. In diesem Frame-Fenster kann ein mousemove auf einem noch // lebenden Treffer-
  • setActive() ausloesen -> ohne diesen Guard Null-Deref auf overlay. if (!overlay) return; const options = listEl.querySelectorAll('.palette-option'); if (options.length === 0) return; activeIndex = Math.max(0, Math.min(idx, options.length - 1)); options.forEach((opt, i) => { opt.setAttribute('aria-selected', i === activeIndex ? 'true' : 'false'); }); const input = overlay.querySelector('.palette-input'); input.setAttribute('aria-activedescendant', options[activeIndex].id); options[activeIndex].scrollIntoView({ block: 'nearest' }); } // ---- schliessen: dialog.js-Cleanup-Muster (remove Listener -> Transition -> Fokus) ---- function close() { if (!overlay) return; document.removeEventListener('keydown', keyHandler); const el = overlay; overlay = null; keyHandler = null; withViewTransition(() => { el.classList.remove('active'); el.remove(); }); if (prevFocus && typeof prevFocus.focus === 'function') prevFocus.focus(); prevFocus = null; } // ---- oeffnen: Overlay nach dialog.js-Muster aufbauen ---- function open() { if (overlay) return; prevFocus = document.activeElement; overlay = document.createElement('div'); overlay.className = 'dialog-overlay palette-overlay'; const box = document.createElement('div'); box.className = 'palette-box'; box.setAttribute('role', 'none'); // ARIA-1.2-Combobox: role=combobox gehoert auf das fokussierbare Textfeld selbst, // nicht auf die Huelle. aria-expanded/haspopup/controls/activedescendant ebenfalls hier. const input = document.createElement('input'); input.className = 'palette-input'; input.type = 'text'; input.setAttribute('role', 'combobox'); input.setAttribute('aria-expanded', 'true'); input.setAttribute('aria-haspopup', 'listbox'); input.setAttribute('aria-label', t('palette.aria_label')); input.setAttribute('aria-autocomplete', 'list'); input.setAttribute('aria-controls', 'palette-listbox'); input.placeholder = t('palette.placeholder'); input.autocomplete = 'off'; input.spellcheck = false; const list = document.createElement('ul'); list.className = 'palette-list'; list.id = 'palette-listbox'; list.setAttribute('role', 'listbox'); list.setAttribute('aria-label', t('palette.list_label')); const live = document.createElement('div'); live.className = 'palette-live'; live.setAttribute('aria-live', 'polite'); const hint = document.createElement('div'); hint.className = 'palette-hint'; hint.textContent = t('palette.hint'); box.append(input, list, live, hint); overlay.appendChild(box); // Klick auf den Overlay-Hintergrund schliesst (wie dialog.js:107) overlay.addEventListener('click', e => { if (e.target === overlay) close(); }); // Live-Filter input.addEventListener('input', () => { results = search(input.value); renderList(list, live); input.setAttribute('aria-activedescendant', activeIndex >= 0 ? 'palette-opt-' + activeIndex : ''); }); // Tastatursteuerung: Pfeile/Enter/Escape + Fokus-Falle (nur input fokussierbar) keyHandler = function (e) { if (e.key === 'Escape') { e.preventDefault(); close(); return; } if (e.key === 'Enter') { e.preventDefault(); if (activeIndex >= 0 && results[activeIndex]) openResult(results[activeIndex]); return; } if (e.key === 'ArrowDown') { e.preventDefault(); setActive(list, activeIndex + 1); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setActive(list, activeIndex - 1); return; } if (e.key === 'Tab') { // Fokus-Falle ueber den Container: das Eingabefeld ist das einzige // fokussierbare Element, also Tab/Shift+Tab immer dorthin zurueck. e.preventDefault(); input.focus(); } }; document.addEventListener('keydown', keyHandler); document.body.appendChild(overlay); // View-Transition uebernimmt das Fade; Fokus ins Eingabefeld withViewTransition(() => { overlay.classList.add('active'); input.focus(); }); } // ---- globaler Ausloeser Strg+K (Meta+K auf Mac) mit Open-Guard ---- document.addEventListener('keydown', e => { if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) { if (overlay) { e.preventDefault(); close(); return; } if (isBlocked()) return; e.preventDefault(); open(); } }); // Persistenter Header-Trigger (BS-08): Klick toggelt die Palette wie Strg+K. const paletteBtn = document.getElementById('btnPalette'); if (paletteBtn) { paletteBtn.addEventListener('click', () => { if (overlay) { close(); return; } if (isBlocked()) return; open(); }); } }